package com.tong.hao.common.configuration.redis;

import cn.buddha.jec.util.TongStringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

/**
 * @author shihao
 * @description Redis分布式锁 -- 单Redis节点的分布式锁
 *
 * <pre>
 * 【可靠性】
 * 为了保证分布式锁可用,需要满足4个条件
 *      互斥性: 在任意时刻,只有一个客户端持有锁。
 *      不会发生死锁: 即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端加锁。
 *      解铃还须系铃人: 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
 *      容错性: 只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
 * </pre>
 */
@Component
@ConditionalOnBean(HaoRedisConfig.class)
@Slf4j
public class HaoRedisDistLock {

    /**
     * Redis 模板类
     */
    @Resource
    private RedisTemplate<String, String> redisTemplate;

    /**
     * Redis分布式锁Key
     */
    private static final String REDIS_DISTRIBUTED_LOCK_PREFIX = "redis_distributed_lock";

    /**
     * Redis 解锁(lua脚本)
     */
    private static final String UNLOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call" +
            "('del', KEYS[1]) else return 0 end";

    /**
     * Redis 解锁成功标志
     */
    private static final Long UNLOCK_SUCCESS = 1L;

    /**
     * <pre>
     * 【获取分布式锁方案一】
     * 1- 原理过程
     *    1). A尝试去获取锁lockKey,通过setnx(lockKey,UUID)命令,对lockkey进行setnx,
     *        将value值设置为UUID;
     *    2). 如果返回值为1,说明Redis服务器中还没有lockKey,也就是没有其他用户拥有这个锁,A就能获取锁成功;
     *    3). 在进行相关业务执行之前,先执行 expire(lockKey),对lockKey设置有效期,防止死锁。
     *        因为如果不设置有效期的话,lockKey将一直存在于Redis中,其他用户尝试获取锁时,
     *        执行到setnx(lockKey,UUID)时,将不能成功获取到该锁。
     *    【不能让一个线程长期占有一个锁而导致死锁。】
     *    4). 执行相关业务;
     *    5). 释放锁,A完成相关业务之后要释放拥有的锁,也就是删除Redis中该锁的内容,del(lockKey),接下来的用户才能进行重新设置锁新值。
     *    ps: 获取锁的方法只有前3步。
     * 2- 说明
     *    1). 互斥性
     *        setnx: set if not exist
     *          如果key不存在,则将key的值设置为value,加锁成功;
     *          如果key已经存在,则setnx命令不做任何动作,意味着加锁失败。
     *        key: 把key当做锁,因为key是唯一的。
     *        value: value存在的意义在于Redis可靠性的第3点(解铃还须系铃人)。通过value我们就知道这把锁
     *             是哪个请求加的了,在解锁的时候就有据可依,目的就是为了防止客户端A加的锁被其他客户端给解锁了。
     *             所以,value也就要保证唯一性。
     *       【key作为锁,value作为钥匙。一把钥匙开一把锁。这也是为什么要返回value的原因了。】
     *    2). 不会发生死锁
     *        expire: 设置锁的过期时间,防止死锁,也就是防止一个线程长期占有一把锁而导致死锁。
     * 3- 问题
     *    1).如果线程A在setnx后,A成功获取锁了,也就是锁已经存到Redis里面了,此时服务器异常关闭或是重启,
     *       将不会"设置锁的有效期",这样的话,锁就不会释放了,进而就会产生死锁。
     *       也就是说, set key value nx 和 expire key seconds 是两个分开的操作,不具备原子性。
     *    so，推荐使用 set key value [EX seconds] [PX milliseconds] NX 来保证 获取锁和设置有效期 的原子性(获取分布式锁方案二)
     * </pre>
     *
     * @param key     Redis的key,分布式的锁
     * @param timeout 设置锁的有效期
     * @param unit    锁的有效期的时间单位,TimeUnit.SECONDS(秒),TimeUnit.MILLISECONDS(毫秒)等
     * @return java.lang.String 返回分布式锁对应的value
     */
    @Deprecated
    public String lockFirst(final String key, final long timeout, final TimeUnit unit) {
        log.info("获取分布式锁方案一");

        // 设置 Redis 的 value值
        String value = TongStringUtils.getUUID();

        String distributedLock = REDIS_DISTRIBUTED_LOCK_PREFIX + key;

        // 获取锁, Redis命令: setnx key value 或者 set key value NX
        Boolean setNxResult = this.redisTemplate.opsForValue().setIfAbsent(distributedLock, value);

        if (null != setNxResult && setNxResult) {
            // 设置有效期, 命令: expire key seconds
            this.redisTemplate.expire(distributedLock, timeout, unit);
            return value;
        } else {
            log.info("没有获取到Redis分布式锁,key: {},ThreadName: {}", distributedLock,
                    Thread.currentThread().getName());
            return null;
        }
    }

    /**
     * <pre>
     * 【获取分布式锁方案二】
     * 将方案一的前三步合并成一个步骤。
     * 使用 set key value [EX seconds] [PX milliseconds] NX 来保证 加锁和设置有效期 的原子性
     * 【注】: 也可以通过lua脚本来保证原子性。
     * </pre>
     *
     * @param key     Redis的key,分布式的锁[key的取值建议: token + requestURI + 微服务名称]
     * @param timeout 设置锁的有效期
     * @param unit    锁的有效期的时间单位,TimeUnit.SECONDS(秒),TimeUnit.MILLISECONDS(毫秒)等
     * @return java.lang.String 返回分布式锁对应的value
     */
    public String lockSecond(final String key, final long timeout, final TimeUnit unit) {
        log.info("获取分布式锁方案二");

        // 设置 Redis 的 value值
        String value = TongStringUtils.getUUID();

        String distributedLock = REDIS_DISTRIBUTED_LOCK_PREFIX + key;

        // 用命令 set key value [EX seconds] [PX milliseconds] NX 来保证 加锁和设置有效期 的原子性
        // 互斥性、不会发生死锁都得到保证
        Boolean setNxExPxResult = this.redisTemplate.opsForValue().setIfAbsent(distributedLock,
                value, timeout, unit);

        if (null != setNxExPxResult && setNxExPxResult) {
            return value;
        } else {
            log.info("没有获取到Redis分布式锁,key: {},ThreadName: {}", distributedLock,
                    Thread.currentThread().getName());
            return null;
        }
    }

    /**
     * <pre>
     * 【释放分布式锁方式一】
     *  1- 方法
     *     直接调用del命令
     *  2- 问题
     *     不判断锁的拥有者,直接解锁的方式。会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
     *     没有保证Redis可靠性的第3点: 解铃还须系铃人
     * <pre/>
     * @param key     Redis的key,分布式的锁
     */
    @Deprecated
    public void unlockFirst(String key) {
        log.info("释放Redis分布式锁方案一");
        String distributedLock = REDIS_DISTRIBUTED_LOCK_PREFIX + key;
        // 释放锁，Redis命令: del key
        this.redisTemplate.delete(distributedLock);
        log.info("释放Redis分布式锁方案一,key: {},ThreadName: {}", distributedLock, Thread.currentThread().getName());
    }

    /**
     * <pre>
     * 【释放分布式锁方式二】
     * 保证Redis可靠性中的 "解铃还须系铃人"
     *  1- 执行步骤
     *      1). 根据key获取value
     *      2). 判断加锁和解锁是不是同一个客户端,即第一步获取到的value和入参value进行比较;
     *      3). 如果是同一个客户端,则说明 锁 被 锁的持有者 解锁。
     *  2- 问题
     *      1). 描述: 如果调用del方法的时候,这把锁已经不属于当前客户端的时候会解锁其他客户端加的锁。
     *      2). 举栗子
     *          A. 前提条件
     *              客户端A和客户端B的key是一样的，也就是在客户端A和客户端B是一个集群的时候才会一样，
     *          微服务之间的key一般是不同的，当然，这个需要设计来约束。
     *          B). 栗子
     *              客户端A加锁，一段时间后客户端A解锁，在执行del之前，锁突然过期了。
     *          此时客户端B尝试加锁成功，然后客户端A再执行del方法，则将客户端B的锁给解锁了。
     *          当然同一个客户端的不同线程之间也可能会出现这种问题。
     *  问题的本质其实就是三步操作(get、判断、del不具备原子性)
     *  so，需要使用lua脚本来保证原子性。
     * <pre/>
     * @param key     Redis的key,分布式的锁
     * @param value   Redis的value(钥匙)
     */
    @Deprecated
    public void unlockSecond(String key, String value) {
        log.info("释放Redis分布式锁方案二");
        String distributedLock = REDIS_DISTRIBUTED_LOCK_PREFIX + key;
        // Redis命令: get key。
        // 根据key获取value
        String sourceValue = this.redisTemplate.opsForValue().get(distributedLock);
        // 判断获取到的sourceValue和入参value是否相同,如果相同则说明是同一个客户端
        // 简单理解: 锁和钥匙是一对一关系(一把锁一把钥匙),把value当做锁的钥匙,只有锁的拥有者才有锁的钥匙。
        if (value.equals(sourceValue)) {
            // 释放锁，Redis命令: del key
            log.info("释放Redis分布式锁方案二,key: {},ThreadName: {}", distributedLock, Thread.currentThread().getName());
            this.redisTemplate.delete(distributedLock);
        }
    }

    /**
     * <pre>
     * 【释放分布式锁方式三】
     * 把方案二中的 get、判断、del释放锁的三个步骤放到lua脚本中完成，保证原子性即可。
     * </pre>
     *
     * @param key   Redis的key,分布式的锁
     * @param value Redis的value(钥匙)
     * @return boolean 返回锁是否释放成功
     */
    public boolean unlockThree(String key, String value) {
        log.info("释放Redis分布式锁方案三");
        String distributedLock = REDIS_DISTRIBUTED_LOCK_PREFIX + key;
        // 定义默认脚本封装类
        RedisScript<Long> redisScript = new DefaultRedisScript<>(UNLOCK_LUA_SCRIPT, Long.class);
        // 执行脚本，第一个参数是RedisScript接口对象, 第二个是Redis的key列表, 最后是参数列表
        Long executeResult = this.redisTemplate.execute(redisScript,
                Collections.singletonList(distributedLock), value);
        return UNLOCK_SUCCESS.equals(executeResult);
    }

}















